昨天先把本機模型選好,今天開始補第一塊地基。
看到「型別提示、dataclass、Pydantic」可能會覺得有點基礎,但如果後面要碰 MCP,這幾個東西其實很難繞過。
原因很簡單。
之後我們會寫出這種 Tool:
@mcp.tool()
def read_file(path: str, max_lines: int = 200) -> str:
"""讀取專案內的一個檔案並回傳內容。"""
對 Python 來說,這只是一個 function。
但對 LLM 來說,它需要知道:
這些資訊最後都會變成一份 JSON Schema。
所以今天想搞懂的其實不是 Pydantic 語法本身,而是:
Python 的型別,最後是怎麼變成 LLM 看得懂的 Tool 定義?
最直接的寫法當然是:
msg = {
"role": "user",
"content": "哈囉"
}
簡單又方便。
但如果哪天手滑:
msg = {
"rols": "user",
"content": "哈囉"
}
Python 不會有任何反應。
這份資料可能一路往下傳,直到其他地方真的去讀 role 時才爆掉。
麻煩的地方就是:
寫錯的地方,跟發現錯誤的地方可能差很遠。
可以先加上 TypedDict:
from typing import Literal, TypedDict
class MessageDict(TypedDict):
role: Literal["system", "user", "assistant", "tool"]
content: str
這時 VS Code、mypy 這些工具就能開始幫忙檢查。
但它主要還是開發階段的保護。
例如:
bad: MessageDict = {
"role": "banana",
"content": 123
}
Type Checker 會不開心,但 Python 執行時還是能跑。
所以 TypedDict 解決的是:
「寫程式時提醒我」
不是:
「執行時幫我擋掉錯誤資料」
再往前一步:
from dataclasses import dataclass, field
@dataclass
class Message:
role: str
content: str = ""
tags: list[str] = field(default_factory=list)
這樣比 dict 舒服很多。
不用自己寫 __init__,印出來也比較清楚,兩個內容相同的物件甚至可以直接比較。
但如果我這樣寫:
wrong = Message(
role=999,
content=["這不是字串"]
)
還是可以建立成功。
因為 Python 的型別標註,本身不代表執行時一定會驗證。
如果資料完全是程式內部自己產生的,其實 dataclass 已經很好用了。
但 Agent 有一個問題:
Tool 的參數很多時候是 LLM 產生的。
既然資料不是我們完全控制的,就需要多一道驗證。
假設之後要做一個 read_file 工具:
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class ReadFileParams(BaseModel):
path: str = Field(
description="相對於專案根目錄的檔案路徑,例如 src/main.py"
)
max_lines: int = Field(
default=200,
ge=1,
le=5000,
description="最多讀取幾行"
)
encoding: Literal["utf-8", "big5"] = Field(
default="utf-8",
description="檔案編碼"
)
@field_validator("path")
@classmethod
def no_escape(cls, value: str) -> str:
if ".." in value or value.startswith("/"):
raise ValueError("路徑必須是相對路徑")
return value
這時候就跟前面不一樣了。
Pydantic 不只記錄型別,還真的會驗證資料。
例如:
ReadFileParams(path="../../etc/passwd")
會直接被擋。
這個:
ReadFileParams(
path="a.py",
max_lines=99999
)
也不會通過。
如果 encoding 傳了一個不在選項裡的值,一樣會失敗。
對 Agent 來說這很重要,因為我們不能假設:
LLM 每一次產生的 Tool Call 都一定正確。
例如:
params = ReadFileParams(
path="README.md",
max_lines="80"
)
原本的 "80" 是字串。
Pydantic 驗證後會轉成:
80
也就是 int。
LLM 產生 Tool Call 時,參數格式不一定永遠跟我們預期的一模一樣。
所以比起讓每一支 Tool 自己處理輸入,我比較希望先統一經過一層驗證。
前面講的東西,真正跟 MCP 接起來的是:
schema = ReadFileParams.model_json_schema()
Pydantic 會直接幫我們產生類似:
{
"properties": {
"path": {
"description": "相對於專案根目錄的檔案路徑,例如 src/main.py",
"type": "string"
},
"max_lines": {
"default": 200,
"minimum": 1,
"maximum": 5000,
"type": "integer"
},
"encoding": {
"default": "utf-8",
"enum": [
"utf-8",
"big5"
],
"type": "string"
}
},
"required": [
"path"
],
"type": "object"
}
前面的東西就全部接起來了:
Field description
↓
description
ge / le
↓
minimum / maximum
Literal
↓
enum
沒有預設值
↓
required
這份 Schema 就是之後描述 Tool 參數的重要資訊。
現在先記住這條線:
Python Type Hint
↓
Pydantic Model
↓
JSON Schema
↓
Tool 定義
到了 Day 7 講 MCP Tools 時,我們會正式看到這份 Schema 在 MCP 裡扮演什麼角色。
到了 Day 10 真正用 Python SDK 寫 MCP Server,再回頭看:
@mcp.tool()
就不會覺得它像什麼黑魔法了。
後面會一直用 Tool,所以我先做一層自己的資料結構:
class ToolSpec(BaseModel):
name: str
description: str
input_schema: dict
@classmethod
def from_pydantic(
cls,
name: str,
description: str,
params: type[BaseModel]
):
return cls(
name=name,
description=description,
input_schema=params.model_json_schema()
)
之後只要:
spec = ToolSpec.from_pydantic(
name="read_file",
description="讀取專案內的一個檔案",
params=ReadFileParams
)
就能把一個 Pydantic Model 轉成 Tool 定義。
這份 ToolSpec 後面會繼續拿去接 Ollama,也會再接進 MCP。
我比較想維持這種做法:
專案裡先有自己的中立格式,再去轉成不同框架需要的格式。
這樣後面就算換模型或換框架,也不用整個專案跟著重寫。
這裡有一個很容易搞混的地方。
假設我們寫:
max_lines: int = Field(
ge=1,
le=5000
)
Schema 裡確實會出現:
{
"minimum": 1,
"maximum": 5000
}
模型看到之後,會知道這個參數大概應該怎麼填。
但這不代表模型一定不會填錯。
所以我會把它拆成兩層:
JSON Schema
↓
告訴模型參數應該怎麼填
Pydantic
↓
程式實際驗證收到的資料
現在先記住一件事就好:
不要因為資料是 LLM 產生的,就預設它一定正確。
等到 Day 19 講 Agent 的安全治理與最小權限時,再把這件事繼續往 Tool 權限與高風險操作延伸。
目前我自己的判斷方式其實很簡單。
如果資料完全來自自己的程式:
dataclass
通常就夠了。
如果資料可能來自:
使用者輸入
LLM Tool Call
API Response
外部服務
我就會比較傾向:
Pydantic
不用因為 Pydantic 很方便,就什麼東西都塞進去。
該簡單的地方還是簡單一點。
今天其實只是在建立一條後面會一直出現的路:
Python Type Hint
↓
Pydantic Model
↓
JSON Schema
↓
Tool 定義
↓
LLM 產生 Tool Call
↓
程式再次驗證
Day 7 講 MCP Tools 時,會正式把這份 Schema 接進 MCP。
Day 10 寫第一個 MCP Server 時,則會看到 SDK 怎麼幫我們把這些步驟包起來。
所以今天表面上是在學 Pydantic。
實際上是在幫後面的 MCP Tool 打地基。
下一篇:
async / await 與 asyncio,順便量出這張卡的天花板。
除了把非同步搞懂,我也會直接實測同一張 GPU 在不同並行數下的表現。
因為後面做到 Multi-Agent 時,一個很現實的問題一定會出現:
Agent 開得更多,真的就會跑得更快嗎?
明天直接測!!!